기능을 잘 만드는 것과 안전하게 만드는 것은 다른 근육이에요. 이번 장은 "누구인지 확인하고(인증), 무엇을 허용할지 정하고(인가), 비밀을 안전하게 보관하고, 흔한 웹 공격을 방어하는" 원리를 정리합니다. 공격 기법을 외우는 게 아니라, 왜 이 방어가 통하는가를 설명할 수 있는 걸 목표로 해요.
🎯 이 장을 끝내면
인증(Authentication)과 인가(Authorization)를 구분하고 401 vs 403을 정확히 골라요.
서버 세션과 JWT 토큰의 상태·무상태 차이, JWT가 서명(위변조 검증)일 뿐 암호화가 아님을 이해해요.
OAuth2(권한 위임)와 OIDC(인증 계층)의 관계와 인가 코드 그랜트 흐름을 설명해요.
비밀번호를 솔트 + 느린 해시(bcrypt/scrypt/Argon2)로 저장하고 TLS로 전송하는 이유를 알아요.
SQL 인젝션·XSS·CSRF·접근 제어 실패의 방어 원리와 Spring Security 필터 체인 개념을 잡아요.
🪪
인증 vs 인가
"누구인가" 와 "무엇을 할 수 있나"
이 둘을 섞으면 401과 403을 계속 잘못 씁니다.
보안의 두 축은 인증(Authentication)과 인가(Authorization)예요. 인증은 "당신이 정말 그 사람이 맞는가"를 확인하는 일(로그인), 인가는 그 확인이 끝난 사람에게 "무엇을 허용할 것인가"를 결정하는 일(권한·역할 검사)입니다. 순서상 인증이 먼저, 인가가 나중이에요.
🪪
인증 (Authentication)
누구인가? — 신원 확인(로그인)
→
🔑
인가 (Authorization)
무엇을 할 수 있나? — 권한·역할 검사
🚦 401 Unauthorized vs 403 Forbidden401 Unauthorized — 사실은 "Unauthenticated". 신원이 확인되지 않았다(로그인 안 됨·토큰 없음·토큰 만료). 클라이언트에게 "자격 증명을 (다시) 제시하라"는 뜻이에요. 403 Forbidden — 신원은 확인됐지만 권한이 없다. 로그인은 했는데 그 리소스에 접근할 역할이 아닌 경우죠. 다시 로그인해도 결과는 같아요.
헷갈릴 때 규칙: "너 누구야?"에 답 못 하면 401, "너인 건 알겠는데 그건 안 돼"면 403.
💡
이름의 역사적 함정. HTTP 상태 코드 401의 공식 이름이 하필 Unauthorized라서 "인가 실패"로 오해하기 쉬운데, 스펙상 의미는 인증(authentication) 실패예요. 실제 권한 부족은 403입니다.
🖼️ 그림으로 보기 — 인증 → 인가 흐름과 401 / 403
🎫
세션 vs 토큰
상태를 서버가 쥐느냐, 토큰이 자체 포함하느냐
JWT는 "암호화"가 아니라 "서명"이에요 — 이 오해가 사고를 부릅니다.
로그인 이후 매 요청마다 "이 사람 아까 로그인한 사람 맞아?"를 다시 확인해야 해요. 이걸 푸는 두 방식이 서버 세션과 토큰(JWT)입니다.
구분
서버 세션 (Stateful)
JWT 토큰 (Stateless)
상태 저장
서버가 세션 저장소(메모리·Redis)에 보관. 쿠키엔 세션 ID만
서버는 저장 안 함. 정보가 토큰 자체에 포함
확장성
서버 간 세션 공유 필요(스티키 세션·중앙 저장소)
서버가 무상태라 수평 확장에 유리
폐기(로그아웃)
서버에서 세션 삭제하면 즉시 무효
발급된 토큰은 만료 전 폐기가 어려움(블랙리스트 필요)
노출 위험
세션 ID만 노출(내용은 서버에)
페이로드가 Base64로 누구나 디코드 가능
🖼️ 그림으로 보기 — 서버 세션 vs JWT 토큰
🗄️
서버 세션 (Stateful)
상태를 서버가 저장(메모리·Redis), 쿠키엔 세션 ID만. 로그아웃 시 즉시 무효화 쉬움. 대신 서버 간 세션 공유가 필요해 확장에 부담.
🎫
JWT 토큰 (Stateless)
정보가 토큰 자체에 포함돼 서버는 저장 안 함 → 수평 확장에 유리. 대신 발급 후 만료 전 폐기가 어렵고, 페이로드는 디코드로 노출됨.
✍️ JWT는 "서명"이지 "암호화"가 아니다
JWT는 header.payload.signature 세 조각을 점(.)으로 이은 문자열이에요. header·payload는 암호화가 아니라 Base64URL 인코딩일 뿐이라, 토큰을 가진 사람은 누구나 페이로드를 그대로 읽을 수 있어요. 서명(signature)은 "이 내용이 위·변조되지 않았음"을 서버 비밀키로 검증할 뿐, 내용을 숨기지 않습니다.
그래서 JWT 페이로드에 비밀번호·주민번호 같은 민감정보를 넣으면 안 돼요. 숨겨야 한다면 별도 암호화(JWE)나 서버 조회가 필요합니다.
⚠️
만료·폐기 문제와 리프레시 토큰. JWT는 무상태라 한번 발급하면 만료(exp) 전에는 강제로 무효화하기 어려워요. 그래서 액세스 토큰은 짧게(수 분~수십 분) 두어 탈취 피해를 줄이고, 수명이 긴 리프레시 토큰으로 조용히 재발급받아요. 리프레시 토큰은 서버가 저장·회전(rotation)·폐기할 수 있게 관리합니다.
🧭
Bearer 스킴. 토큰은 보통 Authorization: Bearer <token> 헤더로 보내요. "Bearer(소지자)"라는 이름처럼 이 토큰을 가진 사람이면 누구든 그 사람 취급이라, 토큰 자체가 곧 열쇠예요 — 반드시 HTTPS로만 전송하고 안전하게 보관해야 합니다.
🔗
OAuth2 · OIDC
권한을 "위임"하고, 그 위에 "인증"을 얹다
"구글로 로그인" 뒤에 숨은 흐름이에요.
OAuth2는 권한 위임(delegated authorization) 프레임워크예요. 사용자의 비밀번호를 제3자 앱에 넘기지 않고도, "내 구글 프로필만 읽어도 돼"처럼 제한된 권한(스코프)을 액세스 토큰 형태로 위임합니다. 원래 목적은 인증이 아니라 "무엇에 접근해도 되는가"였어요.
OIDC(OpenID Connect)는 그 OAuth2 위에 얹은 인증(authentication) 계층이에요. "이 사람이 누구인가"를 ID 토큰(JWT)으로 표준화해 알려줘요. 즉 OAuth2 = 인가, OIDC = 인증이라고 보면 정확합니다.
🙋
① 인가 요청
사용자를 인증 서버로 리다이렉트(scope 요청)
→
🎟️
② 인가 코드
동의 후 앱으로 임시 code 전달
→
🔁
③ 토큰 교환
서버끼리 code→액세스·리프레시·ID 토큰
🖼️ 그림으로 보기 — OAuth2 인가 코드 그랜트 — 4개 역할
🔐 왜 토큰을 바로 안 주고 "인가 코드"를 거칠까인가 코드 그랜트(Authorization Code Grant)는 브라우저에 토큰을 직접 노출하지 않으려는 설계예요. 브라우저는 일회용 code만 받고, 실제 토큰 교환은 백엔드끼리(client secret 사용) 안전한 채널에서 이뤄져요. 최근엔 SPA·모바일을 위해 PKCE를 더해 code 가로채기까지 막습니다.
암묵적(Implicit) 그랜트는 토큰을 URL로 바로 노출해 이제 권장되지 않아요. 표준은 Authorization Code + PKCE입니다.
💡
스코프(scope)는 최소권한의 도구.profile, email처럼 필요한 만큼만 요청하세요. 스코프가 곧 위임 범위라, 넓게 받을수록 사고 시 피해가 커집니다. 이것이 최소 권한 원칙(least privilege)의 실전 적용이에요.
🧂
비밀번호 저장
평문·단순 해시는 사고, 솔트 + 느린 해시가 정답
DB가 털려도 비밀번호는 지켜지게 만드는 게 목표예요.
비밀번호 저장의 대전제는 "우리도 원문을 몰라야 한다"예요. 그래서 원문 대신 단방향 해시를 저장하죠. 하지만 단순 해시만으로는 부족합니다.
방식
문제/특징
평문 저장✕
DB 유출 = 전 계정 즉시 노출. 절대 금지
단순 해시(MD5/SHA-256 1회)✕
너무 빠르고 결정적이라 레인보우 테이블·무차별 대입에 취약
솔트(salt) 추가
계정마다 다른 무작위 값을 섞어 레인보우 테이블·동일 비번 노출을 무력화
느린 해시(bcrypt/scrypt/Argon2)✓
일부러 느리게·비싸게 설계(work factor). 대입 공격 비용을 폭증
🖼️ 그림으로 보기 — 비밀번호 저장 방식
🚫
평문 저장
DB 유출 = 전 계정 즉시 노출. 절대 금지.
⚠️
단순 해시 (MD5·SHA 1회)
너무 빠르고 결정적 → 레인보우 테이블·무차별 대입에 취약.
✅
솔트 + 느린 해시
bcrypt·scrypt·Argon2. 솔트로 미리 계산 차단, work factor로 대입 비용 폭증.
🧂 왜 "솔트 + 느린 해시" 둘 다 필요할까솔트는 계정마다 무작위 값을 섞어, 같은 비밀번호라도 해시가 달라지게 해요 — 미리 계산해 둔 레인보우 테이블이 통하지 않죠. 느린 해시는 계산 비용(work factor)을 일부러 크게 잡아, 공격자가 초당 시도할 수 있는 횟수를 확 줄여요. 하나는 "미리 계산 못 하게", 하나는 "빨리 못 하게"라서 함께여야 완성됩니다.
권장은 Argon2(메모리-하드), 또는 bcrypt/scrypt. MD5·SHA-1은 비밀번호용으로 부적합합니다.
⚠️
저장(rest)과 전송(transit)은 다른 위협이에요. 느린 해시는 저장된 비밀번호를 지키지만, 네트워크로 오가는 값을 지키진 않아요. 로그인 요청이 평문 HTTP로 나가면 중간에서 가로채집니다. 그래서 전송 구간은 반드시 TLS/HTTPS로 암호화해야 해요. "해시했으니 HTTP여도 괜찮다"는 잘못된 생각입니다.
🧭
Spring이라면 직접 구현하지 마세요.PasswordEncoder의 BCryptPasswordEncoder(또는 DelegatingPasswordEncoder)가 솔트 생성·저장·검증을 알아서 처리해요. 인코딩된 문자열 안에 알고리즘·솔트·work factor가 함께 담깁니다.
🕷️
OWASP 취약점
가장 흔한 웹 공격의 방어 원리
공격법 암기가 아니라, 왜 이 방어가 통하는지를 봅니다.
OWASP Top 10은 웹에서 가장 흔하고 위험한 취약점을 정리한 목록이에요. 대표 몇 가지의 방어 원리를 정리합니다.
취약점
핵심 방어
SQL 인젝션
PreparedStatement + 파라미터 바인딩(문자열 연결 금지). 코드와 데이터를 분리
XSS(교차 사이트 스크립팅)
출력 시 이스케이프 + CSP 헤더. 사용자 입력을 코드로 실행시키지 않기
CSRF(요청 위조)
CSRF 토큰 + SameSite 쿠키. 요청이 "우리 화면에서 나온 것"임을 검증
접근 제어 실패
서버에서 매 요청 권한 재검증. URL·ID 조작(IDOR)까지 서버가 막기
민감정보 노출
TLS + 저장 암호화 + 로그·에러 메시지에 비밀 남기지 않기
💉 SQL 인젝션 — 코드와 데이터를 섞지 않는다
문제의 근원은 사용자 입력을 SQL 문자열에 그대로 이어 붙이는 것이에요. 입력이 코드로 해석되면서 쿼리 구조가 바뀌죠. PreparedStatement는 쿼리 구조를 먼저 고정하고 값은 ? 자리에 데이터로만 바인딩하므로, 어떤 입력이 와도 구조가 바뀌지 않아요.
JPA·MyBatis에서도 파라미터 바인딩(:name, #{name})을 쓰면 됩니다. 문자열 연결(${}·+)은 피하세요.
// ✕ 위험: 입력이 쿼리 구조를 바꿀 수 있음
String sql = "SELECT * FROM users WHERE name = '" + input + "'";
// ✓ 안전: 구조 고정 + 값은 데이터로만 바인딩
String sql = "SELECT * FROM users WHERE name = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, input); // 여기서 input은 절대 코드가 되지 않음
🧼 XSS와 CSRF — 비슷해 보여도 방어가 다르다XSS는 악성 스크립트가 페이지에서 실행되는 문제 → 출력 이스케이프(HTML 특수문자를 무해하게)와 CSP(허용된 출처의 스크립트만 실행)로 막아요. CSRF는 로그인된 사용자의 브라우저가 의도치 않게 요청을 보내게 만드는 문제 → 서버가 발급한 CSRF 토큰을 요청에 요구하고, SameSite 쿠키로 타 사이트발 요청에 쿠키가 자동 첨부되지 않게 해요.
한 줄 요약: XSS는 "입력을 코드로 실행시키지 마라", CSRF는 "이 요청이 진짜 우리 화면에서 나왔는지 확인하라".
💡
입력 검증(allowlist)은 공통 기반. 어떤 취약점이든 "신뢰할 수 없는 입력"에서 출발해요. 허용 목록(allowlist) 기반 검증, 출력 맥락에 맞는 인코딩, 서버 측 권한 재확인 — 이 세 습관이 대부분의 사고를 줄입니다. 클라이언트 검증만 믿지 마세요(우회 가능).
🛡️
Spring Security
필터 체인으로 인증·인가를 얹는다
요청이 컨트롤러에 닿기 전, 필터들이 먼저 검문해요.
Spring Security의 핵심 아이디어는 서블릿 필터 체인이에요. 모든 HTTP 요청은 컨트롤러에 도착하기 전에 여러 보안 필터를 통과합니다. 각 필터가 인증 여부·CSRF 토큰·권한 등을 순서대로 검문해요.
🌐
요청
HTTP Request
→
🛡️
Security Filter Chain
인증·CSRF·인가 필터가 순차 검문
→
🎯
컨트롤러
통과한 요청만 도달
🧩 인증 결과는 SecurityContext에 담긴다
필터가 인증에 성공하면 사용자 정보(Authentication)를 SecurityContext에 담아둬요. 이후 컨트롤러·서비스에서 "지금 누가 요청 중인지", "어떤 권한을 가졌는지"를 이 컨텍스트로 확인하죠. 인가는 URL 패턴 기반(authorizeHttpRequests)이나 메서드 기반(@PreAuthorize)으로 선언해요.
기억할 큰 그림: 인증 = SecurityContext에 신원 채우기, 인가 = 그 신원의 권한을 규칙과 대조.
🖼️ 그림으로 보기 — Security 필터 체인
🧭
기본값이 곧 보안 습관. Spring Security는 기본적으로 CSRF 보호 활성화, 세션 고정 보호, 보안 헤더 추가 등을 켜둬요. "잘 안 돼서" 무심코 csrf().disable()를 하기 전에 왜 켜져 있는지부터 이해하세요. 무상태 REST API(JWT)에서 CSRF를 끄는 건 근거가 있지만, 쿠키 세션 기반이라면 신중해야 합니다.